#!/usr/bin/python
# -*- coding: utf-8 -*-
# vim: ai ts=4 sts=4 et sw=4
#
# Courier filter that appends signatures to e-mail messages
# Author: Aleksander Adamowski <aleksander.adamowski@olo.org.pl>
# Based-on: Pawel Sawicki's isoCourierFilter (Pawel J. Sawicki <p-j-s@p-j-s.name>)
# s/-//g
# $Id: sigappender.py 60 2007-07-19 10:43:40Z olo $
#
############################################################################
#                                                                          #
#   This program is free software; you can redistribute it and/or modify   #
#   it under the terms of the GNU General Public License as published by   #
#   the Free Software Foundation; either version 2 of the License, or      #
#   (at your option) any later version.                                    #
#                                                                          #
############################################################################

import courier.control
import courier.xfilter
import ldap
import re
import codecs
import time
import sys
import os
import os.path
import stat
import email
from email.MIMEText import MIMEText
import pickle
import thread

# recipient domains exempt from signature appending:
exemptRecipientDomains              = ['exemptrecip.example.com']
reExemptRecipientDomain = re.compile("[^@]+@("+("|".join(exemptRecipientDomains))+")", re.I)

# sender domains exempt from signature appending:
exemptSenderDomains                 = ['exemptsender.example.com']
reExemptSenderDomain                = re.compile("[^@]+@("+("|".join(exemptRecipientDomains))+")", re.I)
reHTMLBodyClose                     = re.compile('(</BODY>|</BODY>.*</HTML>)', re.I | re.S)
reNonAlnumChars                     = re.compile('[^a-zA-Z0-9]')
reTextType                          = re.compile('^text/', re.I)

# LDAP specific data
ldapServerURI       = 'ldap://localhost:389'                # server
ldapBaseDN          = 'ou=People,o=Example'                 # base DN
ldapSearchScope     = ldap.SCOPE_SUBTREE                    # search scope
ldapAttributes      = ['dn', 'altkomSigTemplate', 'altkomSigExceptionAddresses', 'cn', \
                    'employeeType', 'telephoneNumber', 'mobile', 'mail' ] # returned attributes
attribPrefixes      = {'telephoneNumber':'tel. ', 'mobile':'kom. '}
ldapFilter          = '(uid=%(uid)s)'        # filter
ldapMailAttributes  = ['uid']
ldapMailFilter      = '(&(mail=%(mail)s)(!(ou=others)))'        # filter
ldapBindDN          = ''
ldapBindPassword    = ''
ldapRetries         = 8

# log debug messages
debug_logging       = True

# template directory
templateDir         = '/etc/sigappender/templates'

# Character sets that can encode all the characters contained in all templates:
acceptableCharsets      = [ 'iso-8859-2', 'windows-1250', 'utf-8' ]

# connect to ldap server
ldapConnection      = ldap.initialize(ldapServerURI)
ldapMutex           = thread.allocate_lock()

# bind synchronously
ldapConnection.simple_bind_s(ldapBindDN, ldapBindPassword)

# say hello
sys.stderr.write('Initialized the "sigappender" python filter.\n')

def doFilter(bodyFile, controlFileList):
        global ldapConnection
        global ldapMutex

        """
        Get the message from the queue, but if it has already been processed,
        stop processing and leave it alone:
        """
        try:
            msgfilter = courier.xfilter.XFilter('sigappendfilter', bodyFile,
                controlFileList)
        except courier.xfilter.LoopError, e:
            debug_log('Message already filtered, finishing work and letting other filters continue.')
            # LoopError indicates that we've already filtered this message.
            return ''

        msg = msgfilter.getMessage()

        # get the authenticated user:
        authUser = determine_auth_user(controlFileList, bodyFile, msg)
        if authUser is None:
            debug_log('No authentication data, finishing work and letting other filters continue.')
            return ''

        """
        Check if all recipients' adresses belong to an exempt recipient domain.
        If at least one non-exempt address is present in the recipient table -
        append the signature:
        """
        isNonExemptRecipientPresent = False

        # get the recipients table
        recipients = courier.control.getRecipients(controlFileList)

        # find any non-exempt adresses
        for recipient in recipients:
            if not reExemptRecipientDomain.match(recipient.lower()):
                #debug_log('non exempt recipient found: ' + recipient)
                isNonExemptRecipientPresent = True


        """
        If all recipients are in exempt domains, don't append signature and
        stop processing:
        """
        if not isNonExemptRecipientPresent:
            debug_log('no non-exempt recipients, finishing work and letting other filters continue.')
            return ''

        # prepare local ldap filter
        localLdapFilter = ldapFilter % { 'uid': authUser }

        (ldapResultType, ldapResultData) = threadsafe_ldap_search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapAttributes)

        if len(ldapResultData) != 1 or not ldapResultType == ldap.RES_SEARCH_RESULT:
            # user hasn't been found in the LDAP directory
            debug_log('no LDAP entry or ambiguous search result for %s.' % localLdapFilter)
            debug_log(pickle.dumps(ldapResultData))
            debug_log('Finishing work and letting other filters continue.')
            return '' 

        #debug_log('got sender data from LDAP: ' + pickle.dumps(ldapResultData))
        ldapEntryAttrs = ldapResultData[0][1]

        """
        Check if the sender isn't exempted from signature appending.
        If he is, stop processing:
        """
        # get the sender
        emailSender = courier.control.getSender(controlFileList).lower()
        if 'mail' in ldapEntryAttrs:
            emailSender = ldapEntryAttrs['mail'][0].lower()
        else:
            debug_log('No "mail" attribute for %s!' % authUser)

        if reExemptSenderDomain.match(emailSender):
            debug_log('sender is exempt, finishing work and letting other filters continue.')
            return ''


        if 'altkomSigTemplate' in ldapEntryAttrs:
            altkomSigTemplate = ldapEntryAttrs['altkomSigTemplate'][0]
            altkomSigTemplate = re.sub(reNonAlnumChars,'', altkomSigTemplate)
            debug_log('LDAP entry ' + localLdapFilter + ' has a template: ' + altkomSigTemplate)
        else:
            debug_log('no altkomSigTemplate found for %s, finishing work and letting other filters continue.' % localLdapFilter)
            return '' 

        """
        If any recipient is listen in LDAP as exempt from receiving signatures,
        don't append a signature at all and stop processing:
        """
        if 'altkomSigExceptionAddresses' in ldapEntryAttrs:
            altkomSigExceptionAddresses = ldapEntryAttrs['altkomSigExceptionAddresses']
            for altkomSigExceptionAddr in altkomSigExceptionAddresses:
                for recipient in recipients:
                    if re.match(altkomSigExceptionAddr, recipient.lower()):
                        debug_log('Exception address (%s) matched: %s, not appending a signature, finishing work and letting other filters continue.' % (recipient, altkomSigExceptionAddr))
                        return ''
                        

        templateLdapAttrs = {}
        for ldapAttr in ldapAttributes:
            if ldapAttr in ldapEntryAttrs:
                #debug_log('Adding LDAP attr to dict: ' + ldapAttr + ' : ' + ldapEntryAttrs[ldapAttr][0])
                if ldapAttr in attribPrefixes:
                    templateLdapAttrs[ldapAttr] =  unicode(attribPrefixes[ldapAttr] + ldapEntryAttrs[ldapAttr][0], 'utf-8')
                else:
                    templateLdapAttrs[ldapAttr] = unicode(ldapEntryAttrs[ldapAttr][0], 'utf-8')
                    
            else:
                # Wszystkie klucze uzywane przez szablony musza byc zdefiniowane, wiec ustawiamy na pusty ciag:
                templateLdapAttrs[ldapAttr] = ''

        # get the message

        """
        Import the message as MIME object, process it
        """
        #appendedPlain = False
        #appendedHTML = False
        appended = { 'plain' : False, 'html' : False }

        #debug_log('read the message ' + bodyFile)
        #debug_log('got message body: ' + msg.as_string())
        #globalCharset = msg.get_charset()
        globalCharset = msg.get_param('charset')
        if globalCharset is None or not globalCharset.lower() in acceptableCharsets:
            globalCharset = 'utf-8'
            msg.set_param('charset', globalCharset)
            # Works properly only on leaves?
            #msg.set_charset(globalCharset)
            debug_log('setting global charset to ' + pickle.dumps(globalCharset))
        else:
            debug_log('global charset: ' + pickle.dumps(globalCharset))

        for part in msg.walk():
            debug_log(part.get_content_type())
            #debug_log(pickle.dumps(part.get_params()))
            contentType = str(part.get_content_type()).lower()
            if contentType.startswith('text/'):
                textType = re.sub(reTextType, '', contentType)
                textType = re.sub(reNonAlnumChars,'', textType)
                debug_log('text/ subtype:' + textType)
                if (textType in appended and (not appended[textType])):
                    charset = str(part.get_param('charset'))
                    if charset is None or not charset.lower() in acceptableCharsets:
                        debug_log('setting charset for %s mimepart to %s' % (textType, globalCharset))
                        charset = globalCharset
                        part.set_param('charset', globalCharset)
                        part.set_charset(globalCharset)
                    else:
                        debug_log(textType + ' mimepart charset: ' + pickle.dumps(charset))
                    charset = str(part.get_param('charset'))
                    payload = part.get_payload(decode=True)
                    #debug_log(payload)
                    templateFileName = os.path.join(templateDir, altkomSigTemplate + '.' + textType)
                    if os.access(templateFileName, os.R_OK):
                        if stat.S_ISREG(os.lstat(templateFileName)[0]):
                            debug_log('Loading %s template %s' % (textType, templateFileName))
                            f = codecs.open(templateFileName, 'r', 'utf-8')
                            templateContent = f.read() 
                            f.close()
                            templateContent = templateContent % templateLdapAttrs
                            templateContent = templateContent.encode(charset, 'replace')

                            if textType == 'plain':
                                payload += templateContent
                            elif textType == 'html':
                                if reHTMLBodyClose.search(payload):
                                    debug_log('HTML matches reHTMLBodyClose')
                                    payload = re.sub(reHTMLBodyClose, templateContent + '\n\\1', payload)
                                else:
                                    payload +=  templateContent

                            # In order to transfer-encode the payload:
                            part.set_charset(str(part.get_param('charset')))
                            debug_log(str(part.get_charset().get_body_encoding()))
                            debug_log(str(part.get_param('charset')))
                            debug_log(str(type(part.get_param('charset'))))
                            debug_log('Part charset: in - %s out - %s' % (part.get_charset().input_charset, part.get_charset().output_charset))

                            appended[textType] = True
                            part.set_payload(part.get_charset().body_encode(payload, True))
                            """
                            There's a bug in Python module email in 2.4.3 - it doesn't
                            modify Content-Transfer-Encoding when setting charset for a part
                            that constitutes a non-multipart message.
                            Here's a workaround:
                            """
                            if part.has_key('Content-Transfer-Encoding'):
                                part.replace_header('Content-Transfer-Encoding', part.get_charset().get_body_encoding())
                        else:
                            debug_log('%s template for %s is not a regular file!' % (textType, altkomSigTemplate))
                    else:
                        debug_log('unavailable %s template for %s' % (textType, altkomSigTemplate))
                else:
                    debug_log('part not being modified, content type:' + part.get_content_type())
                

        msgfilter.submit()
        # Return 250, no more filters should be run on this copy.
        debug_log('ending with 050 OK, no more filters should be run on this copy.')
        return '050 OK'

def debug_log(message):
    if debug_logging:
        sys.stderr.write(__name__ + ': ' + message + '\n')

def threadsafe_ldap_search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapAttributes):
    global ldapConnection
    global ldapMutex
    ldapMutex.acquire()
    try:
        for tryNumber in range(ldapRetries):
            if tryNumber == ldapRetries:
                debug_log('Giving up after %d LDAP reconnection attempts.' % ldapRetries)
                return ''
            try:
                # get the signature template id from LDAP:
                ldapResultId = ldapConnection.search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapAttributes)
                ldapResultType, ldapResultData = ldapConnection.result(ldapResultId)
                break
            except ldap.LDAPError, error_message:
                debug_log('LDAP operation error on try %d out of %d: %s' % (tryNumber + 1, ldapRetries, error_message))
                time.sleep(1)
                if tryNumber != ldapRetries:
                    debug_log('retrying LDAP operation')
                try:
                    ldapConnection = ldap.initialize(ldapServerURI)
                    ldapConnection.simple_bind_s(ldapBindDN, ldapBindPassword)
                except ldap.LDAPError, error_message:
                    debug_log('LDAP reconnection error on try %d out of %d: %s' % (tryNumber + 1, ldapRetries, error_message))
    finally:
        ldapMutex.release()

    if ldapMutex.locked():
        ldapMutex.release()
        debug_log('Unreleased ldapMutex detected!')
    return (ldapResultType, ldapResultData)

def mail2uid(mailAddress):
    localLdapFilter = ldapMailFilter % { 'mail': mailAddress }
    debug_log('Searching for auth based on 127.0.0.1 sender: ' + localLdapFilter)
    (ldapResultType, ldapResultData) = threadsafe_ldap_search(ldapBaseDN, ldapSearchScope, localLdapFilter, ldapMailAttributes)
    if len(ldapResultData) != 1 or not ldapResultType == ldap.RES_SEARCH_RESULT:
        debug_log('no LDAP entry or ambiguous search result for %s.' % localLdapFilter)
        debug_log(pickle.dumps(ldapResultData))
        return None
    else:
        mailUID = ldapResultData[0][1]['uid'][0]
        debug_log('Found 127.0.0.1 authUser: ' + mailUID)
        return mailUID

def determine_auth_user(controlFileList, bodyFile, msg):
    authUser = courier.control.getAuthUser(controlFileList, bodyFile)
    if authUser:
        if msg.has_key('X-Pythonfilters-Auth'):
            msg.replace_header('X-Pythonfilters-Auth', authUser)
        else:
            msg.add_header('X-Pythonfilters-Auth', authUser)
    else:
        if msg.has_key('X-Pythonfilters-Auth'):
            authUser = msg.get('X-Pythonfilters-Auth')
        else:
            if courier.control.getSendersIP(controlFileList) == '127.0.0.1':
                """
                Typical webmail installations don't use SMTP AUTH, but the sender address
                in such cases can usually be trusted and searched on.
                """
                authUser = mail2uid(courier.control.getSender(controlFileList))
            else:
                return None
    return authUser

